In [1]:
%matplotlib inline
In [2]:
import pandas as pd
import numpy as np
import yfinance as yf

import numba

import matplotlib as mpl
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
from IPython.display import set_matplotlib_formats

set_matplotlib_formats('svg')
mpl.rcParams['figure.figsize'] = [12, 8]
In [3]:
data = yf.Ticker('^GSPC').history(period='max', auto_adjust=False)["1970":]
In [4]:
# Note that when using ^GSPC, there are no dividends, and it's also 
# not really directly tradeable - I'll do a continuous futures version 
# at some point

# Rest of analysis assumes non-stock allocation is in cash, 
# but in reality you would have some gains from being in bonds as well

# We add transaction costs further down this sheet

rets = data['Adj Close'].apply(np.log).diff().apply(np.exp).sub(1)
ma200 = data.Close.rolling(200).mean()
In [5]:
# The 200-day moving average (MA200) is a lagging, smoother version of the original data
data.Close.plot(logy=True)
ma200.plot(logy=True);
In [6]:
# It can be used for betting on momentum, doing market timing by being long when the close is above the MA200
position = (data.Close > ma200).astype(int)
position[ma200.isnull()] = np.nan
position = position.shift()  # Delay by one day
position.plot()
Out[6]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0cf4e374d0>
In [7]:
def plot_dd(rets):
    c_pl = rets.add(1).cumprod()

    c_pl.plot(lw=1, color='black', logy=True)
    plt.fill_between(
        c_pl.index,
        c_pl,
        c_pl.cummax(),
        facecolor='red',
        alpha=0.5,
    )
In [8]:
# This is the plain old SP500
plot_dd(rets)
In [9]:
# This is with the MA200 crossover timing - the returns are lower but drawdowns are less deep
plot_dd(position * rets)
In [10]:
strats = pd.DataFrame({
    'beta': rets, 
    'ma200': (position * rets)
})
In [11]:
# Slightly worse returns
strats.add(1).apply(np.log).mean().mul(250).apply(np.exp).sub(1)
Out[11]:
beta     0.069351
ma200    0.063267
dtype: float64
In [12]:
# Better worst and average drawdown
(
    strats
    .add(1).cumprod()
    .transform(lambda xs: xs / xs.cummax() - 1)
    .agg(['min', 'mean'])
    .T
)
Out[12]:
min mean
beta -0.567754 -0.113291
ma200 -0.282810 -0.075949
In [13]:
# These results are all before accounting for trading costs!
# Turnover can be awfully high - 20x in some years! You are trading in and out of the market a lot
# Some concerns about overfitting due to sensitivity to exactly when the crossings happen
(
    position.diff().abs()
    .groupby(pd.Grouper(freq='Y')).sum()
    .rename(index=lambda xs: xs.year)
    .plot.bar()
)
Out[13]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0cf7923d10>
In [14]:
# Let's smoothen the signal by acting slowly on it!
@numba.njit
def _smoothen(signal, nsteps):
    pos = np.zeros_like(signal, dtype=np.int64)
    signal = signal * nsteps
    pos[0] = nsteps
    for i in range(1, pos.shape[0]):
        if signal[i] > pos[i - 1]:
            d = 1
        elif signal[i] < pos[i - 1]:
            d = -1
        else:
            d = 0
        pos[i] = pos[i - 1] + d
    return pos / nsteps


def smoothen(signal, nsteps):
    return pd.Series(
        _smoothen(signal.values, nsteps),
        index=signal.index
    )
In [15]:
position_smooth = smoothen(position, 90)
In [16]:
# Smoothed MA200 crossover
plot_dd(position_smooth * rets)
In [17]:
# Turnover is way down
(
    position_smooth.diff().abs()
    .groupby(pd.Grouper(freq='Y')).sum()
    .rename(index=lambda xs: xs.year)
    .plot.bar()
)
Out[17]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0cf4edb4d0>
In [18]:
strats = pd.DataFrame({
    'beta': rets, 
    'ma200': (position * rets),
    'ma200_slow': (position_smooth * rets),
})
In [19]:
# Average annual returns
strats.add(1).apply(np.log).mean().mul(250).apply(np.exp).sub(1)
Out[19]:
beta          0.069351
ma200         0.063267
ma200_slow    0.065746
dtype: float64
In [20]:
# Drawdowns
dd = (
    strats
    .add(1).cumprod()
    .transform(lambda xs: xs / xs.cummax() - 1)
)
dd_agg = dd.agg(['min', 'mean'])
print(dd_agg)
dd_agg.plot.bar()
          beta     ma200  ma200_slow
min  -0.567754 -0.282810   -0.328139
mean -0.113291 -0.075949   -0.066267
Out[20]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0cddf59750>
In [21]:
# Drawdowns side by side
dd.plot(lw=1)
Out[21]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0cddec6a10>
In [22]:
# Original signal slams on and off
position.plot()
Out[22]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0cde21d150>
In [23]:
# New signal drifts in and out
position_smooth.plot()
Out[23]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0cde2ba750>
In [24]:
# The original signal has trades on 4.2% of all days
position.diff().ne(0).mean()
Out[24]:
0.04242233086263996
In [25]:
# Smoothened signal trades (a much smaller amount in aggregate) on ~30% of all days
position_smooth.diff().ne(0).mean()
Out[25]:
0.29514272196814384
In [26]:
# Some years as many as 80% of all days
position_smooth.diff().ne(0).groupby(pd.Grouper(freq='Y')).mean().rename(index=lambda xs: xs.year).plot.bar()
Out[26]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0cf427cbd0>
In [27]:
# Assume 20 bps transaction cost - strategy costs at most 50 bps a year to execute
cost_smooth = position_smooth.diff().abs().mul(20. / 10000)
cost_smooth.groupby(pd.Grouper(freq='Y')).sum().rename(index=lambda xs: xs.year).plot.bar()
Out[27]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0cf4d85ed0>
In [28]:
# Contrast with original signal - a few percent a year!
cost = position.diff().abs().mul(20. / 10000)
cost.groupby(pd.Grouper(freq='Y')).sum().rename(index=lambda xs: xs.year).plot.bar()
Out[28]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0cddc028d0>
In [29]:
# Difference from beta by year
(
    (position_smooth * rets - cost_smooth + 1)
    .div(rets + 1)
    .cumprod().sub(1).plot()
)
Out[29]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0cddadfbd0>
In [30]:
# Difference from beta by year
(
    (position_smooth * rets - cost + 1)
    .div(rets + 1)
    .groupby(pd.Grouper(freq='Y')).prod()
    .sub(1)
    .rename(index=lambda xs: xs.year).plot.bar()
)
Out[30]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0cdd9dd450>
In [31]:
# Side by side, with transaction costs
(
    strats
    .assign(
        ma200_slow=lambda df: df.ma200_slow - cost_smooth,
        ma200=lambda df: df.ma200 - cost,
    ).add(1).cumprod().plot(logy=True)
)
Out[31]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0cdd8563d0>